SPB Git

spb/worthdoing Public

Autonomous investigation agent that discovers, challenges, and ranks things genuinely worth doing — Claude + Firecrawl, Next.js 16, PostgreSQL

TypeScript 91.5% SQL 5.8% CSS 2.2%
7.8 KB · 200 lines tsx
Raw Blame History
1/**2 * WorthDoing.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: src/app/opportunity/[id]/page.tsx6 * Description: Opportunity report page — streamed report, scores with confidence, evidence, competitors, skeptic case.7 */8import Link from "next/link";9import { notFound } from "next/navigation";10import { and, asc, eq, inArray } from "drizzle-orm";11import { db } from "@/lib/db/client";12import {13  opportunities,14  opportunityScores,15  opportunityEvidence,16  opportunityCompetitors,17  competitors,18  evidence,19  sources,20  investigations,21} from "@/lib/db/schema";22import { Markdown } from "@/components/wd/Markdown";23import { ScorePanel } from "@/components/wd/ScorePanel";24import { WorthScore } from "@/components/wd/OpportunityCard";25import { EvidenceKindBadge } from "@/components/wd/badges";2627export const dynamic = "force-dynamic";2829export default async function OpportunityPage({ params }: PageProps<"/opportunity/[id]">) {30  const { id } = await params;31  if (!/^[0-9a-f-]{36}$/i.test(id)) notFound();3233  const [opp] = await db.select().from(opportunities).where(eq(opportunities.id, id));34  if (!opp) notFound();3536  const [inv] = await db.select().from(investigations).where(eq(investigations.id, opp.investigationId));37  const scores = await db.select().from(opportunityScores).where(eq(opportunityScores.opportunityId, id));38  const links = await db.select().from(opportunityEvidence).where(eq(opportunityEvidence.opportunityId, id));39  const evidenceIds = links.map((l) => l.evidenceId);40  const evRows = evidenceIds.length41    ? await db42        .select({43          id: evidence.id,44          kind: evidence.kind,45          quote: evidence.quote,46          summary: evidence.summary,47          strength: evidence.strength,48          sourceUrl: sources.canonicalUrl,49          sourceTitle: sources.title,50          sourceDomain: sources.domain,51        })52        .from(evidence)53        .innerJoin(sources, eq(sources.id, evidence.sourceId))54        .where(and(eq(evidence.investigationId, opp.investigationId), inArray(evidence.id, evidenceIds)))55        .orderBy(asc(evidence.createdAt))56    : [];57  const comps = await db58    .select({59      name: competitors.name,60      url: competitors.url,61      description: competitors.description,62      note: opportunityCompetitors.note,63    })64    .from(opportunityCompetitors)65    .innerJoin(competitors, eq(competitors.id, opportunityCompetitors.competitorId))66    .where(eq(opportunityCompetitors.opportunityId, id));6768  return (69    <article className="mx-auto w-full max-w-4xl px-4 py-8 sm:px-6">70      {inv && (71        <Link72          href={`/investigate/${inv.id}`}73          className="font-mono text-[11px] uppercase tracking-wider text-tele hover:underline"74        >75          ← investigation: {inv.objective}76        </Link>77      )}7879      <header className="mt-4 flex flex-col-reverse items-start justify-between gap-4 sm:flex-row">80        <div className="min-w-0">81          <h1 className="font-heading text-3xl font-semibold leading-tight tracking-tight text-ink sm:text-4xl">82            {opp.title}83          </h1>84          <p className="mt-3 max-w-2xl text-base leading-relaxed text-ink-soft">{opp.summary}</p>85        </div>86        <div className="shrink-0 rounded-xl border border-line bg-card px-5 py-4">87          <WorthScore score={opp.worthScore} confidence={opp.evidenceConfidence} size="lg" />88          <p className="mt-1 text-right font-mono text-[9px] uppercase tracking-widest text-ink-soft">89            worth score90          </p>91        </div>92      </header>9394      <div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">95        <div className="min-w-0">96          {opp.reportMd ? (97            <Markdown>{opp.reportMd}</Markdown>98          ) : (99            <p className="rounded-xl border border-line bg-card p-6 font-mono text-sm text-ink-soft">100              The report has not been synthesized yet.101            </p>102          )}103104          <section className="mt-10">105            <h2 className="mb-4 font-heading text-xl font-semibold text-ink">Evidence trail</h2>106            <ol className="space-y-3">107              {evRows.map((e, i) => (108                <li109                  key={e.id}110                  id={`evidence-${i + 1}`}111                  className="scroll-mt-24 rounded-xl border border-line bg-card p-4"112                >113                  <div className="flex items-center justify-between gap-2">114                    <span className="font-mono text-[11px] font-medium text-ink">[{i + 1}]</span>115                    <div className="flex items-center gap-2">116                      <EvidenceKindBadge kind={e.kind} />117                      <span className="font-mono text-[10px] text-ink-soft">118                        strength {Math.round(e.strength * 100)}%119                      </span>120                    </div>121                  </div>122                  <blockquote className="mt-2 border-l-2 border-line pl-3 text-sm italic leading-relaxed text-ink/85">123                    “{e.quote}”124                  </blockquote>125                  <p className="mt-2 text-[13px] text-ink-soft">{e.summary}</p>126                  <a127                    href={e.sourceUrl}128                    target="_blank"129                    rel="noopener noreferrer"130                    className="mt-2 inline-block truncate font-mono text-[11px] text-tele hover:underline"131                  >132                    {e.sourceTitle ?? e.sourceUrl} · {e.sourceDomain}133                  </a>134                </li>135              ))}136            </ol>137          </section>138        </div>139140        <aside className="space-y-5">141          <section className="rounded-xl border border-line bg-card p-4">142            <h2 className="mb-4 font-mono text-[11px] uppercase tracking-widest text-ink-soft">143              Score breakdown144            </h2>145            <ScorePanel146              scores={scores.map((s) => ({147                dimension: s.dimension,148                score: s.score,149                confidence: s.confidence,150                reasoning: s.reasoning,151              }))}152            />153          </section>154155          <section className="rounded-xl border border-signal/40 bg-signal/5 p-4">156            <h2 className="mb-2 font-mono text-[11px] uppercase tracking-widest text-signal">157              The skeptic’s case158            </h2>159            <p className="text-[13px] leading-relaxed text-ink/90">{opp.skepticCase}</p>160          </section>161162          <section className="rounded-xl border border-rust/30 bg-rust/5 p-4">163            <h2 className="mb-2 font-mono text-[11px] uppercase tracking-widest text-rust">Risks</h2>164            <p className="text-[13px] leading-relaxed text-ink/90">{opp.risks}</p>165          </section>166167          {comps.length > 0 && (168            <section className="rounded-xl border border-line bg-card p-4">169              <h2 className="mb-3 font-mono text-[11px] uppercase tracking-widest text-ink-soft">170                Competitive landscape171              </h2>172              <ul className="space-y-3">173                {comps.map((c) => (174                  <li key={c.name}>175                    {c.url ? (176                      <a177                        href={c.url}178                        target="_blank"179                        rel="noopener noreferrer"180                        className="text-[13px] font-medium text-tele hover:underline"181                      >182                        {c.name}183                      </a>184                    ) : (185                      <span className="text-[13px] font-medium text-ink">{c.name}</span>186                    )}187                    <p className="mt-0.5 text-xs leading-relaxed text-ink-soft">188                      {c.description} {c.note && <span className="italic">— {c.note}</span>}189                    </p>190                  </li>191                ))}192              </ul>193            </section>194          )}195        </aside>196      </div>197    </article>198  );199}200